Completed
Pull Request — master (#79)
by
unknown
35s
created

build.js ➔ localesToPairs   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
c 0
b 0
f 0
nc 1
dl 0
loc 5
rs 9.4285
nop 1
1
import path from 'path';
2
import Zip from 'jszip';
3
import Promise, { all, promisifyAll, reject, resolve } from 'bluebird';
4
import {
5
    complement,
6
    contains,
7
    curry,
8
    drop,
9
    equals,
10
    endsWith,
11
    filter,
12
    head,
13
    identity,
14
    ifElse,
15
    is,
16
    join,
17
    lensProp,
18
    map,
19
    mapObjIndexed,
20
    merge,
21
    over,
22
    propEq,
23
    replace,
24
    sort,
25
    startsWith,
26
    subtract,
27
    takeWhile,
28
    test,
29
    tryCatch,
30
    unary,
31
    union,
32
    without
33
} from 'ramda';
34
import deepmerge from 'deepmerge';
35
import { emitSuccess, emitWarning } from './input';
36
import { getProperties } from './vm';
37
import { compileModulesFromSource, ensureNoImports, inspect } from './module';
38
39
const fs = promisifyAll(require('fs'));
40
41
const defaultFileOptions = { date: new Date(1149562800000) };
42
const requiredFiles = ['package.json', 'index.js'];
43
44
const localeByFile = drop(8)
45
    & takeWhile(complement(equals('.')))
46
    & join('');
47
48
/**
49
 * Converts a list of locale files to pairs containing locale string and content
50
 *
51
 * @param {String[]} localeFiles
52
 * @return {Promise}
53
 */
54
function localesToPairs(localeFiles) {
55
    return all(localeFiles.map(localeFile => fs.readFileAsync(localeFile, 'utf-8')
56
        .then(JSON.parse)
57
        .then(json => [localeByFile(localeFile), json])));
58
}
59
60
/**
61
 * Projects locale for each translatable subfield
62
 *
63
 * @param {String} locale
64
 * @param {Object} config
65
 * @return {Object}
66
 */
67
const project = curry((locale, config) => ({
68
    title: { [locale]: config.title },
69
    description: { [locale]: config.description },
70
    preview: { [locale]: config.preview },
71
    params: mapObjIndexed(param => merge(param,
72
        { description: { [locale]: param.description } }), config.params)
73
}));
74
75
/**
76
 * Lazily runs the extension using all possible listed locales and extracts
77
 * the meta-data.
78
 *
79
 * @param {String} source
80
 * @param {[(String, *)]} locales
81
 * @return {Promise}
82
 */
83
const runInAllLocales = curry((source, locales) =>
84
    compileModulesFromSource(source).then(modules =>
85
        all([['default', {}], ...locales].map(([locale, strings]) =>
86
            getProperties({ name: `precompile-${locale}`, source }, strings, modules)
87
                .then(project(locale))))
88
                .then(ifElse(propEq('length', 1), head, unary(deepmerge.all)))));
89
90
/**
91
 * Creates a meta file where the information about precompilation is stored
92
 *
93
 * @param {Object} locales
94
 * @return {Promise}
95
 */
96
function createMetaFile(locales) {
97
    return fs.writeFileAsync('.meta', JSON.stringify(locales));
98
}
99
100
/**
101
 * Precompiles linked files, generating a .meta file with all the meta data
102
 *
103
 * @param {Object<String, String[]>} { code, files }
104
 * @return {Promise}
105
 */
106
function precompile({ code, files }) {
107
    return resolve(files)
108
        .then(filter(test(/^locales(\/|\\)[a-z]{2,3}(_[A-Z]{2})?\.json$/)))
109
        .then(localesToPairs)
110
        .then(runInAllLocales(code))
111
        .then(createMetaFile)
112
        .return(['.meta', ...files]);
113
}
114
115
/**
116
 * Ensures there are missing no files in order to a allow a basic compilation
117
 * and filter the used modules. It also warns about possible improvements in the
118
 * extensions
119
 *
120
 * @param {String[]} files
121
 * @return {Promise}
122
 */
123
async function filterFiles(files) {
124
    const clearModule = replace(/^\.\//, '');
125
    const resources = files | filter(test(/^((icon\.png)|(README(\.\w+)?\.md))$/));
126
    const missing = without(files, requiredFiles);
127
128
    if (missing.length > 0) {
129
        return reject(Error(`missing ${missing.join(', ')} from the project`));
130
    }
131
132
    if (!contains('icon.png', files)) {
133
        emitWarning('compiling extension without providing an icon.png file');
134
    }
135
136
    const infoFiles = await listFiles('info')
137
        | filter(test(/[a-z]{2}(_[A-Z]{2,3})?\.md/))
138
        | map(path.join('info/', _));
139
140
    return fs.readFileAsync('index.js', 'utf-8')
141
        .then(inspect)
142
        .then(over(lensProp('modules'), filter(startsWith('./'))))
143
        .then(({ code, modules }) => ({
144
            code,
145
            files: union(modules.map(clearModule),
146
                [...resources, ...requiredFiles, ...infoFiles])
147
        }));
148
}
149
150
/**
151
 * Returns all the files in a directory if it exists. Otherwise, return an
152
 * empty array as fallback (everything inside a promise)
153
 *
154
 * @param {String} directory
155
 * @return {String[]}
156
 */
157
function listFiles(directory) {
158
    return fs.lstatAsync(directory)
159
        .then(lstat => lstat.isDirectory() ? fs.readdirAsync(directory) : [])
160
        .catchReturn([]);
161
}
162
163
/**
164
 * Links autocomplete files
165
 *
166
 * @return {Promise}
167
 */
168
function linkAutoComplete() {
169
    return listFiles('autocomplete')
170
        .then(filter(endsWith('.js')) & map(path.join('autocomplete', _)))
171
        .tap(files => all(files.map(file => fs.readFileAsync(file)
172
            .then(ensureNoImports(file)))));
173
}
174
175
/**
176
 * Links locale files
177
 *
178
 * @return {Promise}
179
 */
180
function linkLocales() {
181
    return listFiles('locales')
182
        .then(filter(test(/^[a-z]{2}(_[A-Z]{2,3})?\.json$/)) & map(path.join('locales', _)))
183
        .filter(location => fs.readFileAsync(location)
184
            .then(JSON.parse & is(Object))
185
            .catchReturn(false))
186
        .catchReturn([]);
187
}
188
189
/**
190
 * Links the files to precompilation, including locales and autocomplete
191
 * scripts. For autocomplete files, ensuring it is a valid script without
192
 * requires. For locales, filtering true locale files and appending the full
193
 * qualified name for current files.
194
 *
195
 * @param {Object<String, String[]>} { code, files }
196
 * @return {Promise}
197
 */
198
function linkFiles({ code, files }) {
199
    return all([linkLocales(), linkAutoComplete()])
200
        .spread(union)
201
        .then(union(files) & sort(subtract) & (files => ({ code, files })));
202
}
203
204
/**
205
 * Opens package.json and extrats its contents. Returns a promise containing
206
 * the file list to be zipped and the package.json content parsed
207
 *
208
 * @param {String} dir
209
 * @return {Promise}
210
 */
211
function getProjectName(dir) {
212
    return fs.readFileAsync(path.join(dir, 'package.json'))
213
        .then(JSON.parse & _.name)
214
        .catchThrow(new Error('Failed to parse package.json from the project'));
215
}
216
217
/**
218
 * Generates a zip package using a node buffer containing the necessary files
219
 *
220
 * @param {String} dir
221
 * @param {String[]} files
222
 * @param {String} name
223
 */
224
const createZip = curry((dir, files) => {
225
    const zip = new Zip();
226
    files.forEach(filename => {
227
        zip.file(filename, fs.readFileSync(path.join(dir, filename)), defaultFileOptions);
228
    });
229
    return zip;
230
});
231
232
/**
233
 * Taking account the -o parameter can be used to specify the output directory,
234
 * let's deal with it
235
 *
236
 * @param {String} customPath
237
 * @param {String} filename
238
 * @return {String}
239
 */
240
function resolveOutputTarget(customPath, filename) {
241
    const realPath = path.resolve('.', customPath);
242
243
    const getPath = tryCatch(
244
        realPath => fs.lstatSync(realPath).isDirectory()
245
            ? path.join(realPath, filename)
246
            : realPath,
247
        identity);
248
249
    return getPath(realPath);
250
}
251
252
/**
253
 * Saves the zip file from buffer to the filesystem
254
 *
255
 * @param {String} dir
256
 * @param {Zip} zip
257
 * @param {String} name
258
 */
259
const saveZip = curry((dir, zip, name) => {
260
    const target = resolveOutputTarget(dir, `${name}.rung`);
261
262
    return new Promise((resolve, reject) => {
263
        zip.generateNodeStream({ type: 'nodebuffer', streamFiles: true })
264
            .pipe(fs.createWriteStream(target))
265
            .on('error', reject)
266
            .on('finish', ~resolve(target));
267
    });
268
});
269
270
/**
271
 * Precompiles an extension and generates a .rung package
272
 *
273
 * @param {Object} args
274
 */
275
export default function build(args) {
276
    const dir = path.resolve('.', args._[1] || '');
277
278
    return fs.readdirAsync(dir)
279
        .then(filterFiles)
280
        .then(linkFiles)
281
        .then(precompile)
282
        .then(createZip(dir))
283
        .then(zip => all([zip, getProjectName(dir)]))
284
        .spread(saveZip(args.output || '.'))
285
        .tap(~emitSuccess('Rung extension compilation'));
286
}
287